Skip to content

feat: Support clearing nullable fields with WorkOS::Null - #519

Closed
devin-ai-integration[bot] wants to merge 2 commits into
mainfrom
devin/1784554626-clear-nullable-fields
Closed

feat: Support clearing nullable fields with WorkOS::Null#519
devin-ai-integration[bot] wants to merge 2 commits into
mainfrom
devin/1784554626-clear-nullable-fields

Conversation

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Description

Reported by a customer: there's no way to clear an organization's (or user's) external_id via the Ruby SDK.

Root cause. Every generated service method builds a body hash and calls .compact, and the hand-maintained runtime (base_client.rb) also does body.compact.to_json. Hash#compact drops all nil values, so an explicit external_id: nil is stripped before serialization and never reaches the API as JSON null. That's why:

  • update_organization(external_id: nil)nil stripped, field unchanged
  • update_organization(external_id: "") → API rejects empty string (422)
  • client.request(body: { "external_id" => nil }) → still stripped by base_client's .compact
  • Only a raw Net::HTTP PUT with JSON.generate({ "external_id" => nil }) worked

Fix. Introduce a WorkOS::Null sentinel that serializes to JSON null. nil keeps its existing meaning ("omit / leave unchanged"); WorkOS::Null sends an explicit null to clear the field. This is regeneration-safe — it lives entirely in the hand-maintained runtime, so no oagen-generated files are touched and every existing endpoint gains clear-field support automatically.

# Leaves external_id unchanged (omitted from the request):
WorkOS.client.organizations.update_organization(id: org.id, external_id: nil)

# Clears external_id (sends {"external_id": null}):
WorkOS.client.organizations.update_organization(id: org.id, external_id: WorkOS::Null)

# Same for users:
WorkOS.client.user_management.update_user(id: user.id, external_id: WorkOS::Null)

Implementation:

  • lib/workos/null.rb — new WorkOS::Null sentinel (to_json"null").
  • lib/workos/base_client.rb — body serialization now recursively converts WorkOS::Null → JSON null after .compact, so it works regardless of JSON encoder and even for nested values (e.g. clearing a single metadata key).
  • test/workos/test_null.rb — covers omit-vs-clear for orgs and users, the raw request helper, and nested-hash null.
  • README.md — documents "Clearing nullable fields".

Documentation

Does this require changes to the WorkOS Docs? E.g. the API Reference or code snippets need updates.

[ ] Yes

The SDK README is updated in this PR. A follow-up to the Ruby SDK docs page may be worthwhile to mention WorkOS::Null.

Link to Devin session: https://app.devin.ai/sessions/127c630f46c54bc0be571814c75047e8

Optional parameters left as nil are omitted from the request body, so a
nullable field such as an organization or user external_id could not be
cleared through the SDK. Introduce a WorkOS::Null sentinel that serializes
to JSON null, letting callers explicitly clear a field.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration
devin-ai-integration Bot requested a review from a team as a code owner July 20, 2026 13:37
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author
Original prompt from heather

SYSTEM:
=== BEGIN THREAD HISTORY (in #dse-pre-triage) ===
<most_recent_message>
Heather Faerber (U08CUNLUBT9): @Devin can you update the rubygem based on this feedback?

&gt; Is there a way via the API or rubygem to clear the external_id for an organization (or user)?
&gt;
&gt; In the web UI, we can just delete the field. (It's under Settings | Organization details | Edit details | External ID.)
&gt;
&gt; But when I tried to set it with the rubygem, it doesn't work:
&gt; • nil gets stripped. The request succeeds, but the value doesn't change.
&gt; • "" returns Validation failed
&gt; • Doing the request directly also gets 200, but the value also doesn't change.
&gt;
&gt; ```&gt; org = WorkOS.client.organizations.create_organization(name: "ExtId Clear Test A", external_id: "ext-clear-test-a")
&gt; =&gt; #&lt;WorkOS::Organization object="organization" id="org_01KXT2M2RQYV33VRKJC7CKKNMY" name="ExtId Clear Test A" domains=[] metadata={} external_id="ext-clear-test-a" created_at="2026-07-18T07:39:00.241Z" updated_at="2026-07-18T07:39:00.241Z" allow_profiles_outside_organization=false&gt;
&gt;
&gt; &gt; WorkOS.client.organizations.update_organization(id: org.id, external_id: "")
&gt; (artemis):48:in '&lt;main&gt;': Validation failed (WorkOS::UnprocessableEntityError)
&gt;
&gt; &gt; WorkOS.client.organizations.update_organization(id: org.id, external_id: nil)
&gt; =&gt; `#`&lt;WorkOS::Organization object="organization" id="org_01KXT2M2RQYV33VRKJC7CKKNMY" name="ExtId Clear Test A" domains=[] metadata={} external_id="ext-clear-test-a" created_at="2026-07-18T07:39:00.241Z" updated_at="2026-07-18T07:39:18.505Z" allow_profiles_outside_organization=false&gt;
&gt;
&gt; &gt; WorkOS.client.organizations.get_organization(id: org.id).external_id.inspect
&gt; =&gt; ""ext-clear-test-a""
&gt;
&gt; &gt; WorkOS.client.request(method: :put, path: "/organizations/`#`{org.id}", body: { "external_id" =&gt; nil })
&gt; =&gt; `#`&lt;Net::HTTPOK 200 OK readbody=true&gt;
&gt;
&gt; &gt; Wo... (3272 chars truncated...)

@devin-ai-integration
devin-ai-integration Bot requested a review from a team as a code owner July 20, 2026 13:37
@devin-ai-integration
devin-ai-integration Bot requested a review from nicknisi July 20, 2026 13:37
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration devin-ai-integration Bot changed the title Support clearing nullable fields with WorkOS::Null feat: Support clearing nullable fields with WorkOS::Null Jul 20, 2026
@greptile-apps

greptile-apps Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces WorkOS::Null, a frozen sentinel object, to allow callers to explicitly send JSON null for nullable fields (e.g. external_id) that would otherwise be silently omitted by the existing Hash#compact calls.

  • lib/workos/null.rb defines the singleton sentinel with to_json, as_json, and inspect overrides; base_client.rb is updated to require it explicitly and adds encode_body/materialize_nulls helpers that replace the three verb-specific .compact.to_json one-liners.
  • Tests confirm the omit-vs-clear contract for organisations and users, the raw request helper, and nested-hash null; README documents the new WorkOS::Null API.

Confidence Score: 5/5

Safe to merge; the change is additive and confined to the hand-maintained runtime layer, so no generated files are affected.

The sentinel pattern is correctly implemented: compact strips genuine nil values, materialize_nulls then converts WorkOS::Null to Ruby nil so any JSON encoder serialises it as null. Explicit require "workos/null" in base_client.rb makes the dependency unconditional. Tests are consistent with existing stub patterns and cover the key omit-vs-clear contract.

No files require special attention.

Important Files Changed

Filename Overview
lib/workos/null.rb New frozen singleton sentinel; to_json/as_json/inspect correctly defined before freeze; identity-based equality check in materialize_nulls is the right approach.
lib/workos/base_client.rb encode_body centralises body serialisation; materialize_nulls correctly recurses into hashes and arrays; explicit require "workos/null" added alongside existing require "workos/errors".
test/workos/test_null.rb Five focused tests covering omit-vs-clear at top level and nested level, consistent with existing test patterns (stub returns "{}" for organization/user updates).
README.md New "Clearing nullable fields" section documents the nil-omit vs WorkOS::Null-clear distinction with clear examples.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller
    participant BaseClient
    participant encode_body
    participant materialize_nulls
    participant JSON

    Caller->>BaseClient: "put_request(body: {external_id => WorkOS::Null, name => nil})"
    BaseClient->>encode_body: encode_body(body)
    encode_body->>encode_body: body.compact removes name nil
    encode_body->>materialize_nulls: materialize_nulls(compacted_body)
    materialize_nulls->>materialize_nulls: WorkOS::Null.equal? check true → nil
    materialize_nulls-->>encode_body: "{external_id => nil}"
    encode_body->>JSON: .to_json
    JSON-->>BaseClient: "{"external_id":null}"
    BaseClient-->>Caller: Net::HTTP request sent
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Caller
    participant BaseClient
    participant encode_body
    participant materialize_nulls
    participant JSON

    Caller->>BaseClient: "put_request(body: {external_id => WorkOS::Null, name => nil})"
    BaseClient->>encode_body: encode_body(body)
    encode_body->>encode_body: body.compact removes name nil
    encode_body->>materialize_nulls: materialize_nulls(compacted_body)
    materialize_nulls->>materialize_nulls: WorkOS::Null.equal? check true → nil
    materialize_nulls-->>encode_body: "{external_id => nil}"
    encode_body->>JSON: .to_json
    JSON-->>BaseClient: "{"external_id":null}"
    BaseClient-->>Caller: Net::HTTP request sent
Loading

Reviews (2): Last reviewed commit: "Require workos/null explicitly in base_c..." | Re-trigger Greptile

Comment thread lib/workos/base_client.rb
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Closing in favor of #521. This PR was the non-breaking Ruby-local stopgap using a caller-facing WorkOS::Null sentinel. Per the discussion with @garen.torikian, we went with the cleaner approach implemented properly in oagen for all three SDKs, where callers just pass plain nil/None/NullFields — no public magic constant:

@gjtorikian
gjtorikian deleted the devin/1784554626-clear-nullable-fields branch July 20, 2026 21:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant